Testing ViewModels and Business Logic in Android
Course: Android App Development
Topic: Testing ViewModels and Business Logic
Course Link: Android App Development Course
Demo Link: Register for a Course Demo
Website: JustAcademy
1. Introduction to ViewModels and Business Logic Testing
Testing ViewModels and business logic is an important part of Android application development. It helps developers verify that application data is processed correctly, user actions produce the expected results, and the user interface receives the correct state.
In modern Android applications, developers commonly use the Model-View-ViewModel (MVVM) architecture. In this architecture, the ViewModel manages UI-related state and coordinates interactions with repositories or other data sources. Business logic handles rules such as validating inputs, calculating totals, processing orders, and determining whether an operation is allowed.
Testing these components independently makes applications easier to maintain and reduces the need for expensive, device-dependent tests.
Why Test ViewModels and Business Logic?
- Verify that application rules produce correct results.
- Confirm that ViewModels expose the correct UI state.
- Detect errors before they reach users.
- Validate loading, success, empty, and error states.
- Test user actions such as saving, deleting, and updating records.
- Reduce regressions when application requirements change.
- Improve code quality by encouraging separation of concerns.
- Make debugging easier by isolating failures to individual components.
2. Learning Objectives
After completing this topic, learners should be able to:
- Understand the role of ViewModels in Android applications.
- Explain the difference between UI logic and business logic.
- Understand the MVVM architecture and its testing benefits.
- Write unit tests for ViewModels using JUnit.
- Test Kotlin StateFlow, LiveData, and observable UI state.
- Test business rules, validation functions, and calculations.
- Use fake objects and mocks to isolate dependencies.
- Test coroutine-based ViewModels and asynchronous operations.
- Verify success, failure, loading, and boundary conditions.
- Apply testing best practices to real Android projects.
3. Understanding the ViewModel in Android
A ViewModel is an Android architecture component designed to store and manage UI-related data in a lifecycle-conscious way. It helps separate screen state and presentation-related logic from Activities, Fragments, and composable UI functions.
A ViewModel can communicate with repositories, execute business operations, expose observable state, and respond to user actions. It should generally avoid holding direct references to Activities, Views, or other short-lived UI objects.
Responsibilities of a ViewModel
- Maintain the state required by a screen.
- Respond to user actions.
- Request data from repositories or domain-layer use cases.
- Expose state through StateFlow, LiveData, or other observable mechanisms.
- Coordinate asynchronous work using Kotlin coroutines when appropriate.
- Convert operation results into UI-friendly states.
- Preserve appropriate UI state across configuration changes.
Example of a ViewModel
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.update { currentCount - 1 + 2 }
}
}
In this example, the ViewModel maintains a counter and exposes its value as a read-only StateFlow. The increment() function updates the counter.
The expression in this illustrative example can be simplified to _count.update { it + 1 }, which is the recommended form for a normal counter increment.
4. Understanding Business Logic
Business logic consists of the rules and operations that determine how an application should behave. It is independent of how information is displayed on the screen.
For example, in an e-commerce application, business logic may calculate discounts, validate coupon codes, determine shipping charges, and check whether an order meets the minimum purchase amount.
Examples of Business Logic
| Application |
Business Rule |
Expected Behavior |
| Shopping app |
Apply a discount to eligible orders. |
The final price is calculated correctly. |
| Banking app |
Reject transfers that exceed available funds. |
An invalid transfer is prevented. |
| Login system |
Validate required credentials. |
Invalid inputs are rejected. |
| Attendance app |
Prevent duplicate attendance entries. |
Only valid attendance records are saved. |
| Task manager |
Mark a task as completed. |
The task status changes correctly. |
| Booking app |
Prevent bookings for unavailable slots. |
Unavailable slots cannot be booked. |
Business logic should be tested with different input values, including valid data, invalid data, boundary values, and unexpected conditions.
5. Understanding MVVM Architecture
MVVM stands for Model-View-ViewModel. It is a common architectural pattern for organizing Android application code.
User Interaction
|
v
View / UI
|
v
ViewModel
|
v
Use Case / Repository
|
v
Database or Remote API
Components of MVVM
- Model: Represents application data and related data-access mechanisms.
- View: Displays information and forwards user interactions.
- ViewModel: Manages screen state and coordinates UI-related operations.
- Repository: Provides an abstraction over data sources such as databases and network APIs.
- Use case: Encapsulates a particular business operation when the application architecture uses a domain layer.
How MVVM Improves Testing
Separating responsibilities allows developers to test business rules without launching the UI. ViewModels can be tested by invoking their functions and checking their observable state. Repositories can be replaced with fake implementations to control returned data and errors.
This structure makes tests faster, more predictable, and easier to understand.
6. Types of Tests for ViewModels and Business Logic
| Test Type |
Purpose |
Example |
| Unit testing |
Tests an individual function or class in isolation. |
Verify a discount calculation. |
| ViewModel testing |
Tests state changes and responses to user actions. |
Verify that a loading state changes to success. |
| Use case testing |
Tests a specific business operation. |
Verify order eligibility rules. |
| Repository testing |
Tests data access and coordination between data sources. |
Verify that saved records can be retrieved. |
| Integration testing |
Tests interactions between multiple components. |
Verify a ViewModel and repository interaction. |
| Regression testing |
Checks that existing behavior remains correct after changes. |
Rerun discount and checkout tests after an update. |
7. Setting Up Unit Testing in an Android Project
Android projects using Gradle generally place local unit tests in the src/test directory. These tests commonly run on the local JVM and are suitable for business logic and ViewModels that do not require a real Android runtime.
Common Testing Dependencies
A Kotlin Android project may use dependencies similar to the following. Choose compatible versions based on the project's Android Gradle Plugin, Kotlin, and library versions.
dependencies {
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
The exact versions should be checked against the project's existing dependency configuration. Additional libraries, such as Mockito or a suitable Flow testing library, can be included when needed.
Typical Project Structure
app/
└── src/
├── main/
│ └── java/com/example/app/
│ ├── ui/
│ │ └── CounterViewModel.kt
│ ├── domain/
│ │ └── PriceCalculator.kt
│ └── data/
│ └── ProductRepository.kt
└── test/
└── java/com/example/app/
├── CounterViewModelTest.kt
└── PriceCalculatorTest.kt
8. Writing Unit Tests for Business Logic
The first step in testing business logic is to identify a function that can be tested independently. The function should have clear inputs, defined outputs, and predictable behavior.
Example: Testing a Discount Calculator
Suppose a shopping application applies a percentage discount to an item's price.
class DiscountCalculator {
fun calculateFinalPrice(
price: Double,
discountPercent: Double
): Double {
require(price >= 0.0) {
"Price cannot be negative"
}
require(discountPercent in 0.0..100.0) {
"Discount must be between 0 and 100"
}
return price - (price * discountPercent / 100)
}
}
Writing the Test Class
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class DiscountCalculatorTest {
private val calculator = DiscountCalculator()
@Test
fun calculatesDiscountCorrectly() {
val result =
calculator.calculateFinalPrice(100.0, 20.0)
assertEquals(80.0, result, 0.001)
}
@Test
fun zeroDiscountKeepsOriginalPrice() {
val result =
calculator.calculateFinalPrice(100.0, 0.0)
assertEquals(100.0, result, 0.001)
}
@Test
fun rejectsNegativePrice() {
assertThrows(IllegalArgumentException::class.java) {
calculator.calculateFinalPrice(-10.0, 10.0)
}
}
}
These tests verify normal calculations, a boundary case, and invalid input. Additional tests should cover a 100% discount, a zero price, and a discount percentage outside the permitted range.
Benefits of Testing Business Logic Independently
- Tests run quickly without launching an emulator.
- Failures are easier to locate.
- Rules can be tested before UI development is complete.
- Changes to presentation code are less likely to affect business-rule tests.
- Edge cases can be covered systematically.
9. Testing ViewModel State
A ViewModel often exposes a state object that describes what the UI should display. Testing this state helps verify that the application responds correctly to user actions and asynchronous results.
For example, a screen might have the following states:
- Idle: No operation is in progress.
- Loading: Data is being retrieved or processed.
- Success: The operation completed successfully.
- Error: The operation failed and the UI needs to communicate the problem.
- Empty: The operation succeeded, but there is no data to display.
Example: A Counter ViewModel
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class CounterViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count.asStateFlow()
fun increment() {
_count.update { it + 1 }
}
fun decrement() {
_count.update { it - 1 }
}
fun reset() {
_count.value = 0
}
}
Testing CounterViewModel
import org.junit.Assert.assertEquals
import org.junit.Test
class CounterViewModelTest {
@Test
fun incrementIncreasesCount() {
val viewModel = CounterViewModel()
viewModel.increment()
assertEquals(1, viewModel.count.value)
}
@Test
fun decrementDecreasesCount() {
val viewModel = CounterViewModel()
viewModel.decrement()
assertEquals(-1, viewModel.count.value)
}
@Test
fun resetSetsCountToZero() {
val viewModel = CounterViewModel()
viewModel.increment()
viewModel.increment()
viewModel.reset()
assertEquals(0, viewModel.count.value)
}
}
These tests directly invoke ViewModel functions and inspect the exposed state. They do not need to launch an Activity or interact with a real screen.
10. Testing ViewModels with StateFlow
StateFlow is a Kotlin Flow type that holds a current value and emits state updates to active collectors. It is commonly used to expose UI state from ViewModels.
For simple state checks, the current value can be read through the value property. When a test needs to verify a sequence of emitted states, it should collect the flow using a suitable testing strategy.
Example of a UI State Model
data class ProfileUiState(
val isLoading: Boolean = false,
val name: String = "",
val errorMessage: String? = null
)
Using a single UI state object can make it easier to reason about the complete screen state than maintaining many unrelated flags.
Example Test
@Test
fun initialProfileStateIsEmpty() {
val viewModel = ProfileViewModel(fakeRepository)
assertEquals(
ProfileUiState(),
viewModel.uiState.value
)
}
This example assumes that ProfileViewModel accepts a repository dependency and initializes its state to the default value. Adapt the test to the actual implementation.
Testing State Changes
For a ViewModel that changes state during an operation, verify the initial state and the resulting state. If the operation is asynchronous, use coroutine test utilities and collect state emissions where appropriate.
11. Testing ViewModels That Use Kotlin Coroutines
Many Android ViewModels use Kotlin coroutines to perform asynchronous work. The viewModelScope is commonly used to launch operations tied to the ViewModel lifecycle.
Coroutine-based tests should control scheduling and avoid arbitrary delays. The kotlinx-coroutines-test library provides tools such as runTest and test dispatchers for predictable execution.
Example: Coroutine-Based ViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class MessageViewModel(
private val dispatcher: CoroutineDispatcher =
Dispatchers.Main
) : ViewModel() {
private val _message = MutableStateFlow("Ready")
val message: StateFlow<String> =
_message.asStateFlow()
fun loadMessage() {
viewModelScope.launch(dispatcher) {
_message.value = "Message loaded"
}
}
}
In production, the dispatcher can default to the main dispatcher. Tests can supply a test dispatcher and replace the Main dispatcher when the ViewModel uses viewModelScope.
Creating a Main Dispatcher Rule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher =
StandardTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
}
}
Testing the Coroutine-Based ViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MessageViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun loadMessageUpdatesState() = runTest(
mainDispatcherRule.dispatcher
) {
val viewModel = MessageViewModel(
mainDispatcherRule.dispatcher
)
viewModel.loadMessage()
advanceUntilIdle()
assertEquals(
"Message loaded",
viewModel.message.value
)
}
}
The test dispatcher gives the test control over coroutine execution. Advancing until idle runs pending scheduled work so the test can verify the final state without relying on real-time waiting.
For more complex coroutine code, inject dispatchers through a consistent application-level dispatcher abstraction and use the same test scheduler for cooperating test dispatchers.
12. Testing Repository Dependencies with Fake Objects
A ViewModel often depends on a repository to retrieve or save data. A fake repository is a lightweight implementation that returns controlled test data without connecting to a real database or remote server.
Fakes are useful when the test needs realistic behavior, while mocks are useful when the test needs to verify interactions or provide specific responses.
Repository Interface
data class User(
val id: Int,
val name: String
)
interface UserRepository {
suspend fun getUser(): User
}
Fake Repository
class FakeUserRepository(
private val user: User = User(1, "Asha")
) : UserRepository {
override suspend fun getUser(): User {
return user
}
}
ViewModel Using the Repository
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class UserViewModel(
private val repository: UserRepository,
private val dispatcher: CoroutineDispatcher =
Dispatchers.Main
) : ViewModel() {
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
fun loadUser() {
viewModelScope.launch(dispatcher) {
_user.value = repository.getUser()
}
}
}
Testing the Repository Interaction
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class UserViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun loadUserExposesRepositoryResult() = runTest(
mainDispatcherRule.dispatcher
) {
val repository = FakeUserRepository(
User(7, "Ravi")
)
val viewModel = UserViewModel(
repository,
mainDispatcherRule.dispatcher
)
viewModel.loadUser()
advanceUntilIdle()
assertEquals(
User(7, "Ravi"),
viewModel.user.value
)
}
}
This test verifies that the ViewModel requests data from the repository and exposes the returned user. It does not require a real API or database.
13. Testing Loading, Success, and Error States
Asynchronous operations should be tested under both successful and unsuccessful conditions. A ViewModel that only handles successful responses may fail when a network request times out or a repository throws an exception.
Example UI State
sealed interface DataUiState {
data object Loading : DataUiState
data class Success(val message: String) : DataUiState
data class Error(val message: String) : DataUiState
}
Test Scenarios
| Scenario |
Test Setup |
Expected State |
| Initial loading |
Start a data operation. |
Loading is visible while work is pending. |
| Successful response |
Repository returns valid data. |
Success contains the expected result. |
| Repository failure |
Repository throws a handled exception. |
Error state is exposed. |
| Empty response |
Repository returns an empty collection. |
Empty state or empty success is shown according to requirements. |
| Retry |
First request fails and the next succeeds. |
The UI transitions from error or retry state to success. |
Use a controllable fake repository or mock to simulate pending operations, success, and failure. Verify intermediate states when they are important to the user experience, not just the final result.
14. Testing Input Validation and Boundary Conditions
Input validation is a common part of business logic. Tests should verify valid inputs, invalid inputs, and values at the boundaries of the allowed range.
Example: Password Validation
class PasswordValidator {
fun isValid(password: String): Boolean {
return password.length >= 8
}
}
Unit Tests
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PasswordValidatorTest {
private val validator = PasswordValidator()
@Test
fun acceptsPasswordWithEightCharacters() {
assertTrue(validator.isValid("abc12345"))
}
@Test
fun rejectsPasswordWithFewerThanEightCharacters() {
assertFalse(validator.isValid("abc123"))
}
@Test
fun rejectsEmptyPassword() {
assertFalse(validator.isValid(""))
}
}
This simplified validator checks only length. Real applications may need additional security requirements, server-side validation, and carefully defined password policies. Tests should reflect the actual product requirements.
Boundary Conditions to Consider
- Empty strings and blank input.
- Minimum and maximum allowed values.
- Zero and negative numeric values where relevant.
- Very long text input.
- Missing optional values.
- Duplicate submissions.
- Unexpected repository responses.
- Concurrent or repeated user actions.
15. Testing LiveData-Based ViewModels
Some Android applications expose ViewModel state through LiveData rather than StateFlow. These ViewModels can be tested by observing LiveData and verifying the emitted values.
AndroidX provides testing utilities such as InstantTaskExecutorRule to execute architecture component tasks synchronously in unit tests where appropriate.
Example ViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class NameViewModel : ViewModel() {
private val _name = MutableLiveData("")
val name: LiveData<String> = _name
fun updateName(value: String) {
_name.value = value.trim()
}
}
Example Test
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class NameViewModelTest {
@get:Rule
val instantTaskExecutorRule =
InstantTaskExecutorRule()
@Test
fun updateNameTrimsWhitespace() {
val viewModel = NameViewModel()
viewModel.updateName(" Anaya ")
assertEquals("Anaya", viewModel.name.value)
}
}
The example tests the current LiveData value. If the implementation uses asynchronous background work, the test must also coordinate that work appropriately.
16. Testing Use Cases and Domain Logic
In applications with a domain layer, a use case represents a focused operation such as calculating an order total, registering a user, or retrieving eligible products. Use cases can often be tested independently of Android framework components.
Example: Order Total Use Case
data class OrderItem(
val price: Double,
val quantity: Int
)
class CalculateOrderTotal {
fun execute(items: List<OrderItem>): Double {
require(items.all {
it.price >= 0 && it.quantity >= 0
})
return items.sumOf {
it.price * it.quantity
}
}
}
Unit Test for the Use Case
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class CalculateOrderTotalTest {
private val useCase = CalculateOrderTotal()
@Test
fun calculatesTotalForMultipleItems() {
val items = listOf(
OrderItem(10.0, 2),
OrderItem(5.0, 3)
)
assertEquals(
35.0,
useCase.execute(items),
0.001
)
}
@Test
fun emptyOrderHasZeroTotal() {
assertEquals(
0.0,
useCase.execute(emptyList()),
0.001
)
}
@Test
fun rejectsNegativeQuantity() {
assertThrows(IllegalArgumentException::class.java) {
useCase.execute(
listOf(OrderItem(10.0, -1))
)
}
}
}
Separating business operations into use cases can make complicated rules easier to test, reuse, and maintain. Not every small application requires a dedicated use-case class; the architecture should fit the application's complexity.
17. Mocking Dependencies in ViewModel Tests
Mocking allows a test to replace a dependency with a controlled object. It is useful when verifying that a ViewModel invokes a repository method, passes the correct arguments, or handles a specific error response.
For example, a mocked repository can be configured to return a particular user, return an empty result, or throw an exception. The ViewModel can then be tested against each condition.
Fake Objects vs. Mock Objects
| Feature |
Fake |
Mock |
| Purpose |
Provides a simplified working implementation. |
Controls responses and can verify interactions. |
| Typical use |
Simulating a repository with in-memory data. |
Checking whether a method was called with expected arguments. |
| Behavior |
Usually implemented with explicit logic. |
Configured through a mocking framework. |
| Maintenance |
May require maintaining a small implementation. |
May require maintaining stubbing and verification setup. |
Use the simplest test double that makes the scenario clear. Excessive mocking can make tests tightly coupled to implementation details.
18. Testing ViewModel State Transitions
A state transition describes how the screen state changes after an action or event. Testing these transitions is particularly useful for screens that perform asynchronous operations.
For example, a profile screen may transition through the following states:
Idle
|
v
Loading
|
+------> Success
|
+------> Error
Tests should verify the expected transitions and ensure that stale data, duplicate requests, or previous errors do not cause incorrect UI behavior.
State Transition Test Checklist
- Verify the initial state.
- Verify the loading state when a request begins.
- Verify the success state after a successful response.
- Verify the error state after a handled failure.
- Verify that retrying updates the state correctly.
- Verify that repeated actions do not produce unintended duplicate operations.
- Verify that loading indicators are cleared when operations finish.
19. Common Mistakes in ViewModel and Business Logic Testing
| Mistake |
Why It Is a Problem |
Better Approach |
| Testing only the happy path |
Invalid inputs and failures remain untested. |
Include boundary, error, and empty-state cases. |
| Testing business rules through the UI only |
Tests become slower and harder to diagnose. |
Test isolated rules directly. |
| Using real network services in every unit test |
Tests become unreliable and environment-dependent. |
Use fakes or mocks for isolated tests. |
| Using arbitrary delays |
Tests can become flaky and slow. |
Use coroutine test utilities and deterministic synchronization. |
| Exposing mutable state publicly |
Other classes can modify state unexpectedly. |
Expose read-only StateFlow or LiveData. |
| Ignoring lifecycle behavior |
State may be lost or work may outlive its intended scope. |
Test lifecycle-sensitive behavior where relevant. |
| Over-mocking every dependency |
Tests may become coupled to implementation details. |
Use fakes for straightforward data scenarios. |
| Ignoring error handling |
Failures may crash the app or leave loading indicators active. |
Test exceptions, error states, and recovery. |
| Relying only on code coverage |
Executed lines do not prove correct assertions. |
Review whether tests validate meaningful behavior. |
20. Best Practices for Testing ViewModels and Business Logic
- Keep business rules independent of Android UI classes wherever practical.
- Use constructor injection to supply repositories, use cases, and dispatchers.
- Expose immutable UI state to consumers.
- Use descriptive test names that explain the behavior being verified.
- Write one focused test for each meaningful scenario.
- Test invalid inputs and boundary conditions.
- Use deterministic test dispatchers for coroutine-based code.
- Replace external services with fakes or mocks when appropriate.
- Test both intermediate and final states when state transitions matter.
- Keep tests independent so that one test does not depend on another.
- Use meaningful assertions rather than checking only that no exception occurred.
- Run unit tests regularly during development and in continuous integration.
- Refactor duplicated test setup into clear helper functions or rules.
- Review failing tests carefully before changing expected values.
21. Practical Project: Testing a Login ViewModel
Build a simple login feature with a ViewModel that validates user input and delegates authentication to a repository.
Requirements
- Accept an email address and password.
- Reject blank email or password fields.
- Display a loading state during authentication.
- Display a success state when authentication succeeds.
- Display a useful error state when authentication fails.
- Allow the user to retry after a failure.
Suggested Test Scenarios
| Test Case |
Action |
Expected Result |
| LOGIN-VM-01 |
Submit valid credentials. |
Repository receives the credentials and success is exposed. |
| LOGIN-VM-02 |
Submit a blank email. |
Validation prevents authentication. |
| LOGIN-VM-03 |
Submit a blank password. |
Validation prevents authentication. |
| LOGIN-VM-04 |
Repository returns an authentication failure. |
Error state is exposed. |
| LOGIN-VM-05 |
Authentication takes time. |
Loading state is shown during the operation. |
| LOGIN-VM-06 |
Retry after an error. |
The next operation can succeed and update the state. |
| LOGIN-VM-07 |
Submit valid input repeatedly. |
Repeated requests follow the intended submission policy. |
Project Workflow
- Define a repository interface for authentication.
- Create a UI state model for idle, loading, success, and error states.
- Implement the ViewModel with injected repository dependencies.
- Write unit tests for input validation.
- Create a fake repository for successful and failed authentication scenarios.
- Use coroutine test utilities if authentication is asynchronous.
- Assert the expected state after each operation.
- Run all tests and document any failures.
22. Interview Questions and Answers
Q1. What is a ViewModel in Android?
Answer: A ViewModel is a lifecycle-conscious component that manages UI-related data and state, helping separate presentation logic from Activities, Fragments, and composable UI functions.
Q2. Why should ViewModels be unit tested?
Answer: Unit testing verifies that ViewModels respond correctly to user actions, coordinate with dependencies, and expose the expected UI state without requiring the complete application UI.
Q3. What is business logic?
Answer: Business logic consists of application-specific rules and operations, such as price calculations, input validation, eligibility checks, and order processing.
Q4. What is the difference between business logic and UI logic?
Answer: Business logic defines application rules, while UI logic manages how information and interactions are represented on the screen.
Q5. What is MVVM architecture?
Answer: MVVM is an architectural pattern that separates the View, ViewModel, and model or data responsibilities to improve maintainability and testability.
Q6. What is StateFlow?
Answer: StateFlow is a Kotlin Flow type that maintains a current state value and emits updates to active collectors.
Q7. How do you test a ViewModel that uses coroutines?
Answer: Use the Kotlin coroutines testing library, control dispatchers and scheduling, and verify the resulting state after scheduled work has completed.
Q8. What is dependency injection, and how does it help testing?
Answer: Dependency injection supplies a class with the objects it requires. Tests can inject fake repositories, mock services, or test dispatchers instead of relying on production dependencies.
Q9. What is a fake repository?
Answer: A fake repository is a simplified implementation of a repository interface that returns controlled data or simulates specific behavior for tests.
Q10. What is the difference between a fake and a mock?
Answer: A fake provides a lightweight working implementation, while a mock is commonly configured to return responses and verify interactions.
Q11. How do you test error handling in a ViewModel?
Answer: Configure a dependency to fail, invoke the ViewModel operation, and assert that the expected error state is exposed and the application remains stable.
Q12. Why should tests cover boundary conditions?
Answer: Boundary tests help identify defects involving minimum values, maximum values, empty input, invalid ranges, and other edge cases.
Q13. What is the purpose of a Main dispatcher test rule?
Answer: It replaces the main coroutine dispatcher with a test dispatcher during tests, allowing code that uses the main dispatcher or ViewModel scope to be executed predictably.
Q14. How can ViewModel tests be made more reliable?
Answer: Use deterministic test data, controlled dependencies, appropriate coroutine synchronization, isolated state, and meaningful assertions.
Q15. Should all business logic be placed in a ViewModel?
Answer: No. ViewModels should manage UI-related state and coordinate operations. Complex or reusable business rules are often better placed in domain-layer use cases or other independently testable classes.
23. Practice Exercises
- Write unit tests for a function that validates email addresses.
- Create a ViewModel that increments, decrements, and resets a counter.
- Test a shopping cart ViewModel with a fake product repository.
- Test a ViewModel that exposes loading, success, and error states.
- Write tests for a discount calculator with valid and invalid percentages.
- Create a fake authentication repository that simulates successful and failed login attempts.
- Test a coroutine-based ViewModel using
runTest.
- Write tests for a use case that calculates the total value of an order.
- Test empty-state handling when a repository returns no data.
- Run your tests through Gradle and record the results.
24. Quick Revision Checklist
- Understand the purpose of ViewModels and business logic.
- Know how MVVM separates UI, state management, and data operations.
- Write local unit tests using JUnit.
- Test StateFlow or LiveData values and state transitions.
- Use fake repositories to isolate ViewModels.
- Test asynchronous operations with coroutine test utilities.
- Verify success, loading, error, and empty states.
- Cover invalid inputs and boundary cases.
- Use dependency injection to make components easier to test.
- Run regression tests after changing application rules.
25. Conclusion
Testing ViewModels and business logic helps Android developers build reliable, maintainable, and predictable applications. ViewModel tests verify UI-related state and user-action behavior, while business logic tests verify the rules that drive application decisions.
JUnit, Kotlin coroutines testing utilities, StateFlow, LiveData, and fake repositories provide a practical foundation for testing these components. By separating responsibilities, controlling dependencies, testing failure scenarios, and automating regression checks, developers can detect defects early and deliver higher-quality Android applications.
Explore Android App Development Training
Continue learning Android development with JustAcademy and explore the course details and demo registration options below.
Course Details: Android App Development Training
Register for a Demo: Register for a Course Demo
Official Website: JustAcademy